EternumPOS · Documentación de diseño

Notas técnicas del MVP

Esquema de base de datos local (SQLite) y arquitectura de sincronización offline-first para el POS. Documento de referencia para el equipo de desarrollo — acompaña al prototipo de la pantalla de ventas.

⚠️
Esto es documentación de diseño, no código de producción. El esquema y la arquitectura son la base conceptual sobre la que el equipo de Tauri/Rust debe construir. El prototipo (Punto de Venta.html) simula esta lógica en memoria para validar la UX.
01 / BASE DE DATOS

Esquema SQLite

Cuatro dominios: catálogo, ventas, cuenta corriente y caja. Cada registro generado localmente usa TEXT con UUID como clave primaria para evitar colisiones al sincronizar (ver sección 02).

Productos y códigos de barras

Un producto puede tener varios códigos (presentaciones, lotes reempaquetados), por eso los códigos viven en su propia tabla 1‑a‑N.

CREATE TABLE products (
  id            TEXT PRIMARY KEY,        -- UUID
  name          TEXT NOT NULL,
  category      TEXT,
  cost_price    INTEGER NOT NULL,        -- centavos, evita floats
  sale_price    INTEGER NOT NULL,
  stock         INTEGER NOT NULL DEFAULT 0,
  min_stock     INTEGER NOT NULL DEFAULT 0,
  unit          TEXT DEFAULT 'un',        -- un | kg | carga
  price_version INTEGER NOT NULL DEFAULT 1, -- ver sync
  updated_at    TEXT NOT NULL,            -- ISO 8601 UTC
  is_active     INTEGER NOT NULL DEFAULT 1
);

CREATE TABLE product_barcodes (
  barcode    TEXT PRIMARY KEY,           -- el código ES la clave
  product_id TEXT NOT NULL REFERENCES products(id)
);
CREATE INDEX idx_barcode ON product_barcodes(barcode);

Ventas y detalle

El detalle congela el precio al momento de la venta (unit_price) para que un cambio de precio posterior no altere tickets históricos.

CREATE TABLE sales (
  id          TEXT PRIMARY KEY,          -- UUID generado offline
  shift_id    TEXT NOT NULL REFERENCES shifts(id),
  total       INTEGER NOT NULL,
  payment     TEXT NOT NULL,             -- efectivo|debito|credito|billetera|fiado
  cash_given  INTEGER,                   -- solo efectivo
  change_due  INTEGER,                   -- vuelto
  client_id   TEXT REFERENCES clients(id),-- solo fiado
  sold_at     TEXT NOT NULL,             -- ISO 8601 UTC
  sync_status TEXT NOT NULL DEFAULT 'pending' -- pending|synced
);

CREATE TABLE sale_items (
  id         TEXT PRIMARY KEY,
  sale_id    TEXT NOT NULL REFERENCES sales(id),
  product_id TEXT NOT NULL REFERENCES products(id),
  qty        INTEGER NOT NULL,
  unit_price INTEGER NOT NULL,           -- precio congelado
  line_total INTEGER NOT NULL
);

Clientes y cuenta corriente (fiado)

El saldo deudor se mantiene como columna desnormalizada para lectura rápida en caja, pero cada movimiento queda registrado para auditoría.

CREATE TABLE clients (
  id            TEXT PRIMARY KEY,
  name          TEXT NOT NULL,
  phone         TEXT,
  balance       INTEGER NOT NULL DEFAULT 0, -- saldo deudor
  credit_limit  INTEGER NOT NULL DEFAULT 0,
  updated_at    TEXT NOT NULL
);

CREATE TABLE account_movements (
  id        TEXT PRIMARY KEY,
  client_id TEXT NOT NULL REFERENCES clients(id),
  sale_id   TEXT REFERENCES sales(id),    -- NULL si es un pago
  amount    INTEGER NOT NULL,             -- + fía / − paga
  kind      TEXT NOT NULL,               -- charge | payment
  created_atTEXT NOT NULL
);

Cierre de caja (turnos)

Los totales por método se calculan sumando sales del turno; en shifts sólo se persiste apertura, cierre y conteo real para detectar diferencias.

CREATE TABLE shifts (
  id            TEXT PRIMARY KEY,
  cashier       TEXT NOT NULL,
  opening_float INTEGER NOT NULL,         -- saldo inicial efectivo
  opened_at     TEXT NOT NULL,
  closed_at     TEXT,                     -- NULL = turno abierto
  counted_cash  INTEGER,                  -- conteo real al cierre
  expected_cash INTEGER,                  -- float + ventas efectivo
  difference    INTEGER                   -- sobrante / faltante
);
02 / SINCRONIZACIÓN

Arquitectura offline-first

El comercio opera siempre contra SQLite local; la nube es un espejo eventual. Dos flujos independientes y unidireccionales que nunca compiten por el mismo dato, eliminando la mayoría de los conflictos por diseño.

SQLite local = fuente de verdad operativa Nube = catálogo maestro + respaldo Sincroniza cuando hay internet

El problema de los IDs y por qué UUID

Si cada caja usara AUTOINCREMENT, dos comercios (o dos cajas offline) generarían la venta #1042 y colisionarían al subir a una base central. La solución es que el cliente genere el ID con un UUID v4/v7 al crear el registro, offline, sin coordinación con el servidor.

UUIDClave primaria global. Se genera en el dispositivo; es único sin consultar al servidor.
updated_atTimestamp UTC para resolver “quién gana” en una actualización (last-write-wins).
sync_statusMarca local pending → synced. La nube nunca lo ve.

Flujo A — Bajada de precios (nube → local)

El catálogo es de una sola dirección: lo edita el dueño en el panel central, nunca el cajero. Por eso no hay conflicto: el local sólo lee.

1
La nube versiona el catálogo

Cada cambio de precio incrementa price_version global.

2
El local pregunta “¿hay novedades?”

Al recuperar internet envía su max(price_version) conocido y recibe sólo el delta.

3
Upsert por UUID

INSERT … ON CONFLICT(id) DO UPDATE. Como el id es estable, actualizar es idempotente: aplicar dos veces el mismo delta no rompe nada.

Flujo B — Subida de ventas (local → nube)

Las ventas son inmutables y append-only: una vez cobradas no se editan. Esto las hace triviales de sincronizar — sólo se insertan, nunca se actualizan.

1
Cobrar escribe local con sync_status='pending'

La venta queda firme en SQLite aunque no haya internet. El cajero nunca espera a la red.

2
Un worker drena la cola

Cuando hay conexión, sube en lote todas las pending con su UUID ya asignado.

3
La nube hace insert idempotente

Si una venta llega dos veces (reintento por timeout), el UUID duplicado se ignora con ON CONFLICT DO NOTHING. Cero ventas dobladas.

4
Confirmación → synced

El servidor responde los UUID aceptados; el local los marca y deja de reenviarlos.

Por qué esto evita conflictos

  • Particionar la escritura: el catálogo lo escribe sólo la nube; las ventas sólo el local. Nadie edita el mismo registro desde dos lados.
  • IDs generados en el cliente (UUID): sin secuencias centralizadas, no hay colisión posible entre cajas.
  • Idempotencia: reenviar por una caída de red no duplica datos (ON CONFLICT).
  • Datos inmutables: las ventas no se actualizan, así que “last-write-wins” casi nunca se necesita; cuando se necesita (precio, stock), gana el updated_at más reciente.
  • Stock: se mantiene autoritativo en el local (es donde ocurre la venta física). La nube lo recibe como informe, no lo sobrescribe.
EternumPOS — Notas técnicas del MVP · Documento vivo. Prototipo interactivo: Punto de Venta.html